
const htmlStr = `
<h1>Hello world!</h1>

<h1>&nbsp;jsPDF + html2canvas A4分页截断 完美解决方案（含代码 + 案例）</h1>

<p><a href="https://juejin.cn/user/4332493267283560" target="_blank"><img alt="" src="https://p3-passport.byteimg.com/img/user-avatar/2d144b1f17e342ee7949aea8e5117fb5~100x100.awebp" /></a></p>

<p><a href="https://juejin.cn/user/4332493267283560" target="_blank">赛博丁真Damon</a><a href="https://juejin.cn/user/4332493267283560" target="_blank"><img alt="lv-3" src="https://lf3-cdn-tos.bytescm.com/obj/static/xitu_juejin_web/img/lv-3.7938ebc.png" /></a></p>

<p>2022年09月01日 19:24&nbsp;&middot;&nbsp;&nbsp;阅读 2552&nbsp;编辑</p>

<h1>业务需求</h1>

<p>网页<strong>html</strong>生成A4大小分页的<strong>pdf</strong>，翻遍了整个互联网发现没有很系统的整理与分析，甚至对<strong>jsPDF</strong>的解析也没有几篇。遇到过几次，用的比较多，完成代码编写后特此整理分析，自我记录。</p>

<h1>业务难点</h1>

<ol>
	<li>存在图片/组件/文字被分割的现象，即分页处理</li>
	<li>包括页头、页脚、上下安全间隔的情况</li>
	<li>富文本分页情况</li>
</ol>

<h1>处理思路</h1>

<p>通过<strong>深度搜索优先遍历</strong>，从顶部遍历需要转换的<strong>HTML</strong>节点， 并将节点分为三种情况进行处理（<strong>1. 普通节点。2. 需要进行分页处理并且内部可能包含也需要分页处理子节点的节点。3. 需要进行分页内部不包含需要分页处理的节点，即深度搜索的终点节点</strong>），通过<strong>从高到低</strong>遍历维护一个<strong>分页数组pages</strong>，该数组记录每一页的起始位置，如：<strong>pages[0]</strong>&nbsp;对应&nbsp;<strong>第一页起始位置</strong>，&nbsp;<strong>pages[1]</strong>&nbsp;对应&nbsp;<strong>第二页起始位置</strong></p>

<p>图解如下：</p>

<p><img alt="分页图解.png" src="https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/b59d0241459245d3ab2e42418f2cadc9~tplv-k3u1fbpfcp-zoom-in-crop-mark:3024:0:0:0.awebp?" /></p>

<p>通过深度遍历后得出每页起始位置的数组，遍历数组，通过<strong>jspdf</strong>的<strong>addImage</strong>接口对<strong>canvas</strong>进行画面截取，由于<strong>addImage</strong>只能<strong>固定位置的左上角起始点</strong>，不能进行<strong>非常精确的上下定位截取</strong>（下一节会详解<code>addImage</code>)，会造成<strong>截取多余的内容</strong>（如上图<strong>页面1</strong>中<strong>pages[1]</strong>&nbsp;下方的内容会和&nbsp;<strong>页面2</strong>&nbsp;中&nbsp;<strong>pages[1]</strong>&nbsp;下方的内容会一样（除长度外），而<strong>页面1</strong>中<strong>pages[1]</strong>&nbsp;下方的内容是多余的（是属于<strong>页面2</strong>的内容））因此需要对页面不需要的内容&nbsp;<code>使用jspdf的addBlank进行空白遮挡处理</code>。</p>

<h1><code>jsPDF.addImage</code>详解</h1>

<p>官方文档链接<a href="https://link.juejin.cn/?target=https%3A%2F%2Fartskydj.github.io%2FjsPDF%2Fdocs%2Fmodule-addImage.html" target="_blank">addImage - Documentation (artskydj.github.io)</a></p>

<p><img alt="image.png" src="https://p1-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/7d8fb79a2f1a479db7e5fa8f9d95fb6c~tplv-k3u1fbpfcp-zoom-in-crop-mark:3024:0:0:0.awebp?" /></p>

<p>需要注意的点是<strong>坐标（x,y）</strong>&nbsp;的取值， (x,y)对应的是添加图片的左上角取值，宽高则是根据转化成canvas的宽高取值，图解如下</p>

<p><img alt="image.png" src="https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/fab3a568117249f5b99bbbcd65e4fc69~tplv-k3u1fbpfcp-zoom-in-crop-mark:3024:0:0:0.awebp?" /></p>

<p>因此在对一个长图片进行截取时，往往将y值设未负数，让需要截取图片的起始位置落于当前的pdf页面内，在当前案例下，每一页的图片摆放坐标<strong>y = -pages[i]</strong></p>

<p><img alt="image.png" src="https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/aa4670e1370c433a959c9ba5134aafd1~tplv-k3u1fbpfcp-zoom-in-crop-mark:3024:0:0:0.awebp?" /></p>

<h1><code>jsPDF.rect</code>详解</h1>

<p>文档链接&nbsp;<a href="https://link.juejin.cn/?target=https%3A%2F%2Fartskydj.github.io%2FjsPDF%2Fdocs%2Fmodule-context2d.html%23~rect" target="_blank">context2d - Documentation (artskydj.github.io)</a></p>

<p><img alt="image.png" src="https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/4bc126050d36429ba446438950ba2ed8~tplv-k3u1fbpfcp-zoom-in-crop-mark:3024:0:0:0.awebp?" /></p>

<p>该接口的参数&nbsp;<strong>（x,y）坐标、宽高</strong>&nbsp;与addImage接口的一致 当前pdf页需要的内容的高度为&nbsp;<code>pages[i] - pages[i-1]</code>， 除去顶部这个高度外以下的内容都是不需要的，因此得到每一页添加空白的<strong>y坐标值为</strong><code>- pages[i] - pages[i-1]</code>，高度h为<code>一页pdf的高度（此处为A4页的高度） - pages[i] - pages[i-1]</code>，宽度为A4宽度，x为0， 图解如下：</p>

<p><img alt="image.png" src="https://p1-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/614731c7ab644216a79e7eaa860d70a5~tplv-k3u1fbpfcp-zoom-in-crop-mark:3024:0:0:0.awebp?" /></p>

<h1>深度优先遍历三种类型的节点</h1>

<p>通过深度优先遍历操作，可以从高到低去遍历需要进行跨页判断的元素，检测是否跨页，并记录分页点，从而避免跨页问题。</p>

<h3>1. 普通节点</h3>

<p>当遍历到普通节点，即不需要进行分页判断的节点时，只需要进行&nbsp;<strong>2步操作</strong>：</p>

<ol>
	<li>
	<p><strong>当前节点距离顶部的高度 - pages最后一位元素的值（即上一页的分界点）得出的差值是否 大于 页面的高度</strong>&nbsp;， 如果大于,则证明当前节点已经跨页，进行操作<strong>pages.push(pages[pages.length - 1] + 一页PDF的高度)</strong></p>
	</li>
	<li>
	<p>对子节点进行深度遍历</p>
	</li>
</ol>

<h3>2. 需要进行跨页判断，且内部也含有&nbsp;<code>可能跨页/需要进行跨页判断</code>&nbsp;的节点</h3>

<p>当元素进行到该类型的节点时， 需要进行<strong>3步操作</strong>：</p>

<ol>
	<li>
	<p>需要进行与<strong>普通节点第一步</strong>相同的判断</p>
	</li>
	<li>
	<p><strong>（检测当前节点距离顶部的距离 + 节点自身的高度） 是否大于 （<code>pages</code>&nbsp;最后一位元素（即当前页 顶部位置） + 一页PDF的高度（当前指A4的高度））</strong></p>
	</li>
</ol>

<p>如果条件为真，则证明该节点属于跨页元素，<strong>距离页面顶部距离的值<code>top</code></strong>&nbsp;是分页点，往<code>pages</code>中&nbsp;<strong>push</strong>&nbsp;<code>top</code></p>

<ol>
	<li>且由于内部还存在需要进行跨页检测的节点，因此需要对子节点进行深度遍历</li>
</ol>

<h3>3. 需要进行跨页判断，但内部不含有<code>可能跨页/需要进行跨页判断</code>&nbsp;的节点， 即深度终点</h3>

<p>该节点只需要进行 内部含有<code>可能跨页/需要进行跨页判断</code>&nbsp;的节点 的第一第二步操作， 由于内部不再含有，因此不需要遍历子节点，为搜索的叶子节点。</p>

<h1>html2Canvas生成图片模糊导致导出的PDF也模糊的问题</h1>

<p>通过&nbsp;<code>scale</code>&nbsp;参数， 对<code>canvas</code>进行等比放大，可以使<code>canvas</code>生成的图片更清晰。</p>

<p>代码如下</p>

<pre>
<code>// 将元素转化为canvas元素
// 通过 放大 提高清晰度
// width为内容宽度
async function toCanvas(element, width) {
  // canvas元素
  const canvas = await html2canvas(element, {
   // allowTaint: true, // 允许渲染跨域图片
    scale: window.devicePixelRatio * 3  // 增加清晰度
  });
  // 获取canavs转化后的宽度
  const canvasWidth = canvas.width;
  // 获取canvas转化后的高度
  const canvasHeight = canvas.height;
  // 高度转化为PDF的高度
  const height = (width / canvasWidth) * canvasHeight;
  // 转化成图片Data
  const canvasData = canvas.toDataURL(&#39;image/jpeg&#39;, 1.0);
  //console.log(canvasData)
  return { width, height, data: canvasData };
}
复制代码</code></pre>

<h1>样例及代码</h1>

<p>gitee仓库：&nbsp;<a href="https://link.juejin.cn/?target=https%3A%2F%2Fgitee.com%2Fliuloser%2Foutput_pdf_demo" target="_blank">output_pdf_demo: jsPDF + html2canvas , 网页HTML导出A4格式PDF 处理分页切割问题 (gitee.com)</a></p>

<p><code>npm install</code>&nbsp;&amp;&nbsp;<code>npm run serve&nbsp;</code>即可运行</p>

<p><img alt="image.png" src="https://p1-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/39006ae873ce48d49bceb77c9fb75709~tplv-k3u1fbpfcp-zoom-in-crop-mark:3024:0:0:0.awebp?" /></p>

<p>分页效果：</p>

<p>富文本分页：</p>

<p><img alt="image.png" src="https://p1-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/32169733cb1c4270a3138c964717d159~tplv-k3u1fbpfcp-zoom-in-crop-mark:3024:0:0:0.awebp?" /></p>

<p>table行分页：</p>

<p><img alt="image.png" src="https://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/fd3bfb10af684e21929d2696bd4ba874~tplv-k3u1fbpfcp-zoom-in-crop-mark:3024:0:0:0.awebp?" /></p>

<p>组件分页：</p>

<p><img alt="image.png" src="https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/2f7ea03ac04440279863f2b8fc9e9850~tplv-k3u1fbpfcp-zoom-in-crop-mark:3024:0:0:0.awebp?" /></p>

<h1>样例注意事项</h1>

<p>样例比上述讲的情况内，引入了<strong>页眉、页脚、还有上下左右间距的情况</strong>，图解如下：</p>

<p><img alt="image.png" src="https://p1-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/27f5f709cde14291a418905f79e3663c~tplv-k3u1fbpfcp-zoom-in-crop-mark:3024:0:0:0.awebp?" /></p>

<p>需要做的额外处理：</p>

<ol>
	<li>图片摆放的<strong>Y</strong>坐标由原来的<code>-pages[i]</code>&nbsp;变成了&nbsp;<code>baseY + 页头元素高度 - pages[i]</code></li>
	<li>中间实际内容部分与页眉/页脚之间的边距也需要进行遮白处理</li>
	<li>内容的高度才为PDF页面的实际高度，判断分页的依据应该以内容高度为准</li>
	<li>富文本文字的分页处理</li>
</ol>

<h1>核心代码</h1>

<pre>
<code>import jsPDF from &#39;jspdf&#39;;
import html2canvas from &#39;html2canvas&#39;;
import { message } from &#39;ant-design-vue&#39;;
const A4_WIDTH = 592.28;
const A4_HEIGHT = 841.89;
// 将元素转化为canvas元素
// 通过 放大 提高清晰度
// width为内容宽度
async function toCanvas(element, width) {
  // canvas元素
  const canvas = await html2canvas(element, {
   // allowTaint: true, // 允许渲染跨域图片
    scale: window.devicePixelRatio * 2  // 增加清晰度
  });
  // 获取canavs转化后的宽度
  const canvasWidth = canvas.width;
  // 获取canvas转化后的高度
  const canvasHeight = canvas.height;
  // 高度转化为PDF的高度
  const height = (width / canvasWidth) * canvasHeight;
  // 转化成图片Data
  const canvasData = canvas.toDataURL(&#39;image/jpeg&#39;, 1.0);
  //console.log(canvasData)
  return { width, height, data: canvasData };
}
/**
 * 生成pdf(A4多页pdf截断问题， 包括页眉、页脚 和 上下左右留空的护理)
 * @param {Object} param
 * @param {HTMLElement} param.element - 需要转换的dom根节点
 * @param {number} [param.contentWidth=550] - 一页pdf的内容宽度，0-592.28
 * @param {string} [param.filename=&#39;document.pdf&#39;] - pdf文件名
 * @param {HTMLElement} param.header - 页眉dom元素
 * @param {HTMLElement} param.footer - 页脚dom元素
 */
export async function outputPDF({ element, contentWidth = 550,
  footer, header, filename = &quot;测试A4分页.pdf&quot; }) {
  if (!(element instanceof HTMLElement)) {
    return;
  }
  // jsPDFs实例
  const pdf = new jsPDF({
    unit: &#39;pt&#39;,
    format: &#39;a4&#39;,
    orientation: &#39;p&#39;,
  });

  // 一页的高度， 转换宽度为一页元素的宽度
  const { width, height, data } = await toCanvas(element, contentWidth);

  // 添加页脚
  async function addHeader(header, pdf, contentWidth) {
    const { height: headerHeight, data: headerData, width: hWidth } = await toCanvas(header, contentWidth);
    pdf.addImage(headerData, &#39;JPEG&#39;, 0, 0, contentWidth, headerHeight);
  }

  // 添加页眉
  async function addFooter(pageNum, now, footer, pdf, contentWidth) {
    const newFooter = footer.cloneNode(true);
    newFooter.querySelector(&#39;.pdf-footer-page&#39;).innerText = now;
    newFooter.querySelector(&#39;.pdf-footer-page-count&#39;).innerText = pageNum;
    document.documentElement.append(newFooter);
    const { height: footerHeight, data: footerData, width: fWidth } = await toCanvas(newFooter, contentWidth);
    pdf.addImage(footerData, &#39;JPEG&#39;, 0, A4_HEIGHT - footerHeight, contentWidth, footerHeight)

  }

  // 添加
  function addImage(_x, _y, pdf, data, width, height) {
    pdf.addImage(data, &#39;JPEG&#39;, _x, _y, width, height);
  }

  // 增加空白遮挡
  function addBlank(x, y, width, height, pdf) {
    pdf.setFillColor(255, 255, 255);
    pdf.rect(x, y, Math.ceil(width), Math.ceil(height), &#39;F&#39;);
  };

  // 页脚元素 经过转换后在PDF页面的高度
  const { height: tfooterHeight } = await toCanvas(footer, contentWidth)

  // 页眉元素 经过转换后在PDF的高度
  const { height: theaderHeight } = await toCanvas(header, contentWidth);

  // 距离PDF左边的距离，/ 2 表示居中 
  const baseX = (A4_WIDTH - contentWidth) / 2;        // 预留空间给左边
  // 距离PDF 页眉和页脚的间距， 留白留空
  const baseY = 15;

  // 出去页头、页眉、还有内容与两者之间的间距后 每页内容的实际高度
  const originalPageHeight = (A4_HEIGHT - tfooterHeight - theaderHeight - 2 * baseY);

  // 元素在网页页面的宽度
  const elementWidth = element.offsetWidth;

  // PDF内容宽度 和 在HTML中宽度 的比， 用于将 元素在网页的高度 转化为 PDF内容内的高度， 将 元素距离网页顶部的高度  转化为 距离Canvas顶部的高度
  const rate = contentWidth / elementWidth

  // 每一页的分页坐标， PDF高度， 初始值为根元素距离顶部的距离
  const pages = [rate * getElementTop(element)];

  // 获取元素距离网页顶部的距离
  // 通过遍历offsetParant获取距离顶端元素的高度值
  function getElementTop(element) {
    let actualTop = element.offsetTop;
    let current = element.offsetParent;

    while (current &amp;&amp; current !== null) {
      actualTop += current.offsetTop;
      current = current.offsetParent;
    }
    return actualTop;
  }



  // 遍历正常的元素节点
  function traversingNodes(nodes) {
    for (let i = 0; i &lt; nodes.length; ++i) {
      const one = nodes[i];
      // 需要判断跨页且内部存在跨页的元素
      const isDivideInside = one.classList &amp;&amp; one.classList.contains(&#39;divide-inside&#39;);
      // 图片元素不需要继续深入，作为深度终点
      const isIMG = one.tagName === &#39;IMG&#39;;
      // table的每一行元素也是深度终点
      const isTableCol = one.classList &amp;&amp; ((one.classList.contains(&#39;ant-table-row&#39;)));
      // 特殊的富文本元素
      const isEditor = one.classList &amp;&amp; (one.classList.contains(&#39;editor&#39;));
      // 对需要处理分页的元素，计算是否跨界，若跨界，则直接将顶部位置作为分页位置，进行分页，且子元素不需要再进行判断
      let { offsetHeight } = one;
      // 计算出最终高度
      let offsetTop = getElementTop(one);

      // dom转换后距离顶部的高度
      // 转换成canvas高度
      const top = rate * (offsetTop)

      // 对于需要进行分页且内部存在需要分页（即不属于深度终点）的元素进行处理
      if (isDivideInside) {
        // 执行位置更新操作
        updatePos(rate * offsetHeight, top, one);
        // 执行深度遍历操作
        traversingNodes(one.childNodes);
      }
      // 对于深度终点元素进行处理
      else if (isTableCol || isIMG) {
        // dom高度转换成生成pdf的实际高度
        // 代码不考虑dom定位、边距、边框等因素，需在dom里自行考虑，如将box-sizing设置为border-box
        updatePos(rate * offsetHeight, top, one);
      }
      else if (isEditor) {
        // 执行位置更新操作
        updatePos(rate * offsetHeight, top, one);
        // 遍历富文本节点
        traversingEditor(one.childNodes)
      }
      // 对于普通元素，则判断是否高度超过分页值，并且深入
      else {
        // 执行位置更新操作
        updateNomalElPos(top)
        // 遍历子节点
        traversingNodes(one.childNodes);
      }
    }
    return;
  }

  // 对于富文本元素，观察所得段落之间都是以&lt;p&gt; / &lt;img&gt; 元素相隔，因此不需要进行深度遍历 (仅针对个人遇到的情况)
  function traversingEditor(nodes) {
    // 遍历子节点
    for (let i = 0; i &lt; nodes.length; ++i) {
      const one = nodes[i];
      let { offsetHeight } = one;
      let offsetTop = getElementTop(one);
      const top = contentWidth / elementWidth * (offsetTop)
      updatePos(contentWidth / elementWidth * offsetHeight, top, one);
    }
  }

  // 普通元素更新位置的方法
  // 普通元素只需要考虑到是否到达了分页点，即当前距离顶部高度 - 上一个分页点的高度 大于 正常一页的高度，则需要载入分页点 
  function updateNomalElPos(top) {
    if (top - (pages.length &gt; 0 ? pages[pages.length - 1] : 0) &gt; originalPageHeight) {
      pages.push((pages.length &gt; 0 ? pages[pages.length - 1] : 0) + originalPageHeight);
    }
  }

  // 可能跨页元素位置更新的方法
  // 需要考虑分页元素，则需要考虑两种情况
  // 1. 普通达顶情况，如上
  // 2. 当前距离顶部高度加上元素自身高度 大于 整页高度，则需要载入一个分页点
  function updatePos(eheight, top) {
    // 如果高度已经超过当前页，则证明可以分页了
    if (top - (pages.length &gt; 0 ? pages[pages.length - 1] : 0) &gt;= originalPageHeight) {
      pages.push((pages.length &gt; 0 ? pages[pages.length - 1] : 0) + originalPageHeight);
    }
    // 若 距离当前页顶部的高度 加上元素自身的高度 大于 一页内容的高度, 则证明元素跨页，将当前高度作为分页位置
    else if ((top + eheight - (pages.length &gt; 0 ? pages[pages.length - 1] : 0) &gt; originalPageHeight) &amp;&amp; (top != (pages.length &gt; 0 ? pages[pages.length - 1] : 0))) {
      pages.push(top);
    }
  }

  // 深度遍历节点的方法
  traversingNodes(element.childNodes);
  // 可能会存在遍历到底部元素为深度节点，可能存在最后一页位置未截取到的情况
  if (pages[pages.length - 1] + originalPageHeight &lt; height) {
    pages.push(pages[pages.length - 1] + originalPageHeight);
  }
  //console.log({ pages, contentWidth, width,height })



  // 根据分页位置 开始分页
  for (let i = 0; i &lt; pages.length; ++i) {
    message.success(\`共\${pages.length}页， 生成第\${i + 1}页\`)
    // 根据分页位置新增图片
    addImage(baseX, baseY + theaderHeight - pages[i], pdf, data, width, height);
    // 将 内容 与 页眉之间留空留白的部分进行遮白处理
    addBlank(0, theaderHeight, A4_WIDTH, baseY, pdf);
    // 将 内容 与 页脚之间留空留白的部分进行遮白处理
    addBlank(0, A4_HEIGHT - baseY - tfooterHeight, A4_WIDTH, baseY, pdf);
    // 对于除最后一页外，对 内容 的多余部分进行遮白处理
    if (i &lt; pages.length - 1) {
      // 获取当前页面需要的内容部分高度
      const imageHeight = pages[i + 1] - pages[i];
      // 对多余的内容部分进行遮白
      addBlank(0, baseY + imageHeight + theaderHeight, A4_WIDTH, A4_HEIGHT - (imageHeight), pdf);
    }
    // 添加页眉
    await addHeader(header, pdf, A4_WIDTH)
    // 添加页脚
    await addFooter(pages.length, i + 1, footer, pdf, A4_WIDTH);
    
    // 若不是最后一页，则分页
    if (i !== pages.length - 1) {
      // 增加分页
      pdf.addPage();
    }
  }
  return pdf.save(filename)
}
复制代码</code></pre>

<h1>参考文档及博文</h1>

<p><a href="https://link.juejin.cn/?target=https%3A%2F%2Fartskydj.github.io%2FjsPDF%2Fdocs%2FjsPDF.html" target="_blank">jsPDF - Documentation (artskydj.github.io)</a></p>

<p><a href="https://link.juejin.cn/?target=https%3A%2F%2Fallenchinese.github.io%2Fhtml2canvas-docs-zh-cn%2Fdocs%2Fhtml2canvas-configuration.html" target="_blank">配置型 | HTML2CANVAS 中文文档 (allenchinese.github.io)</a></p>

<p><a href="https://link.juejin.cn/?target=https%3A%2F%2Fwww.jianshu.com%2Fp%2F257513ab0717" target="_blank">【原创】jspdf+html2canvas生成多页pdf防截断处理 - 简书 (jianshu.com)</a></p>

<p>I&#39;m an instance of <a href="https://ckeditor.com">CKEditor</a>.</p>
`
export default eventHandler(async (req, res) =>  {
  return {
    htmlStr: htmlStr
  }
})
